package daybook.io.formatter;

import daybook.model.*;
import daybook.model.Comparators;
import daybook.model.transaction.*;
import daybook.util.Pair;
import daybook.util.Utils;
import daybook.util.tree.Tree;

import java.io.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;

import static daybook.Resources.Templates.*;
import static daybook.model.transaction.TransactionSummary.amountsCollector;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.reducing;

public class ToString {
    private final TemplateEngine templateEngine;

    public ToString(TemplateEngine templateEngine) {
        this.templateEngine = templateEngine;
    }

    // help type inference
    private static <T> Callable<T> f(Callable<T> callable) {
        return callable;
    }

    private static <T> Iterable<T> iterable(Iterable<T> iterable) {
        return iterable;
    }

    private static String toList(Collection<String> ss) {
        if (ss.size() < 5) {
            return ss.stream().collect(Collectors.joining(","));
        } else {
            final String lineSep = System.lineSeparator();
            return ss.stream()
                    .map(s -> "* " + s)
                    .collect(Collectors.joining(lineSep, lineSep, lineSep));
        }
    }

    private static String amount(BigDecimal amount) {
        return amount.signum() > 0 ? "+" + amount.toPlainString() : amount.toPlainString();
    }

    private static <T> void travelTree(Tree<T> tree, int n, BiConsumer<T, Integer> consumer) {
        if (tree.isLeaf()) {
            consumer.accept(tree.getValue(), n);
        } else {
            consumer.accept(tree.getValue(), n);
            for (Tree<T> subTree: tree.getChildren()) {
                travelTree(subTree, n + 1, consumer);
            }
        }
    }

    public String statistics(TransactionSummaryStatistics statistics) {
        final StringWriter writer = new StringWriter();
        writeStatistics(statistics, writer);
        return writer.toString();
    }

    public void writeStatistics(TransactionSummaryStatistics statistics, Writer writer) {
        templateEngine.write(STRING_STATISTICS, statistics, writer);
    }

    private static Map<String, Object> accountModel(Account account, ChartOfAccounts chartOfAccounts) {
        final Map<String, Object> map = new HashMap<>();
        map.put("code", account.getCode());
        map.put("name", account.getName());
        map.put("fullName", chartOfAccounts.findFullNameByCode(account.getCode()));
        map.put("type", account.getType().name().toLowerCase());
        map.put("parentCode", account.getParentCode());
        map.put("description", account.getDescription());
        map.put("attributes", toList(account.getAttributes()));
        map.put("aliases", toList(account.getAliases()));
        return map;
    }

    private static Map<String, Object> commodityModel(Commodity commodity) {
        final Map<String, Object> map = new HashMap<>();
        map.put("name", commodity.getName());
        map.put("currency", commodity.isCurrency());
        map.put("reciprocalOfFraction", (int) Math.pow(10, commodity.getFractionDigitCount()));
        map.put("description", commodity.getDescription());
        return map;
    }

    private static String chartOfAccountsToString(ChartOfAccounts chartOfAccounts) {
        final StringBuilder sb = new StringBuilder();
        final BiConsumer<Account, Integer> consumer = (account, depth) -> {
            sb.append(Utils.spaces(depth * 3))
                    .append(account.getName()).append(" (").append(account.getCode()).append(")")
                    .append(System.lineSeparator());
        };
        chartOfAccounts.getTree().getChildren().forEach(t -> travelTree(t, 0, consumer));
        return sb.toString();
    }

    private static Map<String, Object> bookModel(Book book) {
        final Map<String, Object> map = new HashMap<>();
        map.put("accountNameSeparator", book.getAccountNameSeparator());
        map.put("defaultCurrency", book.getDefaultCurrency());
        map.put("chartOfAccounts", chartOfAccountsToString(book.getChartOfAccounts()));
        map.put("accounts", iterable(() -> book.getChartOfAccounts().getTree().getChildren().stream()
                .flatMap(Tree::stream).map(account -> accountModel(account, book.getChartOfAccounts())).iterator()));
        map.put("commodities", iterable(() -> book.getCommodities().values().stream()
                .map(ToString::commodityModel).iterator()));
        return map;
    }

    public String book(Book book) {
        final StringWriter writer = new StringWriter();
        writeBook(book, writer);
        return writer.toString();
    }

    public void writeBook(Book book, Writer writer) {
        templateEngine.write(STRING_BOOK, bookModel(book), writer);
    }

    public String transaction(Transaction transaction, Book book) {
        return transaction.toPrettyString(book);
    }

    private static String toOrFromAccounts(LedgerEntry ledgerEntry) {
        return ledgerEntry.getTransaction().getEntries().stream()
                .filter(item -> !item.equals(ledgerEntry.getTransactionEntry()))
                .map(item -> item.getAccount().getName())
                .collect(Collectors.joining(","));
    }

    private static Map<String, Object> ledgerEntryModel(LedgerEntry ledgerEntry) {
        final Map<String, Object> map = new HashMap<>();
        map.put("date", ledgerEntry.getDate());
        map.put("check", ledgerEntry.isChecked() ? "*" : "?");
        map.put("description", ledgerEntry.getDescription());
        map.put("entryDescription", ledgerEntry.getTransactionEntry().getDescription());
        map.put("amount", amount(ledgerEntry.getAmount()));
        map.put("hasItems", !ledgerEntry.getItems().isEmpty());
        map.put("items", ledgerEntry.getItems().stream()
                .map(item -> item.getCost().toPlainString() + " @ " + item.getCount().toPlainString())
                .collect(Collectors.joining(",", "{", "}")));
        map.put("balance", amount(ledgerEntry.getBalance()));
        map.put("toOrFromAccounts", toOrFromAccounts(ledgerEntry));
        return map;
    }

    private static Map<String, Object> ledgerModel(Map<Account, List<LedgerEntry>> ledger, ChartOfAccounts chartOfAccounts) {
        return Collections.singletonMap("ledger", iterable(() -> ledger.entrySet().stream()
                .sorted(Comparator.comparing(Map.Entry::getKey, Comparators.accountComparator))
                .map(e -> {
                    final Map<String, Object> map = new HashMap<>();
                    map.put("account", accountModel(e.getKey(), chartOfAccounts));
                    map.put("ledgerEntries", iterable(() -> e.getValue().stream().map(ToString::ledgerEntryModel).iterator()));
                    return map;
                }).iterator()));
    }

    public String ledger(Map<Account, List<LedgerEntry>> ledger, ChartOfAccounts chartOfAccounts) {
        final StringWriter writer = new StringWriter();
        writeLedger(ledger, chartOfAccounts, writer);
        return writer.toString();
    }

    public void writeLedger(Map<Account, List<LedgerEntry>> ledger, ChartOfAccounts chartOfAccounts, Writer writer) {
        templateEngine.write(STRING_LEDGER, ledgerModel(ledger, chartOfAccounts), writer);
    }

    private static String balanceTreeToString(Tree<Pair<Account, BigDecimal>> tree) {
        final StringBuilder sb = new StringBuilder();
        final BiConsumer<Pair<Account, BigDecimal>, Integer> consumer = (val, dep) -> {
            sb.append(Utils.spaces(dep * 3))
                    .append(val.first().getName())
                    .append(": ")
                    .append(amount(val.second()))
                    .append(System.lineSeparator());
        };
        tree.getChildren().forEach(t -> travelTree(t, 0, consumer));
        return sb.toString();
    }

    private static Map<String, Object> balanceSheetModel(NavigableMap<LocalDate, Tree<Pair<Account, BigDecimal>>> balanceSheets, LocalDate startDate) {
        final List<Map<String, Object>> bss = new ArrayList<>(balanceSheets.size());
        LocalDate lastDate = startDate;
        for (Map.Entry<LocalDate, Tree<Pair<Account, BigDecimal>>> entry: balanceSheets.entrySet()) {
            final Map<String, Object> map = new HashMap<>();
            map.put("startDate", lastDate);
            map.put("endDate", entry.getKey().plusDays(-1L));
            final Tree<Pair<Account, BigDecimal>> balanceTree = entry.getValue();
            map.put("hasBalanceTree", balanceTree != null);
            if (balanceTree != null) {
                map.put("balanceTree", balanceTreeToString(balanceTree));
                final Map<Account.Type, BigDecimal> typeToAmount =
                        balanceTree.getChildren().stream()
                                .map(Tree::getValue)
                                .collect(groupingBy(pair -> pair.first().getType(),
                                        reducing(BigDecimal.ZERO, Pair::second, BigDecimal::add)));
                final BigDecimal asset = typeToAmount.getOrDefault(Account.Type.ASSET, BigDecimal.ZERO);
                final BigDecimal liability = typeToAmount.getOrDefault(Account.Type.LIABILITY, BigDecimal.ZERO);
                final BigDecimal equity = typeToAmount.getOrDefault(Account.Type.EQUITY, BigDecimal.ZERO);
                final BigDecimal revenue = typeToAmount.getOrDefault(Account.Type.REVENUE, BigDecimal.ZERO);
                final BigDecimal expense = typeToAmount.getOrDefault(Account.Type.EXPENSE, BigDecimal.ZERO);
                map.put("asset", amount(asset));
                map.put("liability", amount(liability));
                map.put("totalEquity", amount(equity.add(revenue).add(expense)));
                map.put("equity", amount(equity));
                map.put("revenue", amount(revenue));
                map.put("expense", amount(expense));
            }
            bss.add(map);
            lastDate = entry.getKey();
        }

        return Collections.singletonMap("balanceSheets", bss);
    }

    public String balanceSheet(NavigableMap<LocalDate, Tree<Pair<Account, BigDecimal>>> balanceSheets, LocalDate startDate) {
        final StringWriter writer = new StringWriter();
        writeBalanceSheet(balanceSheets, startDate, writer);
        return writer.toString();
    }

    public void writeBalanceSheet(NavigableMap<LocalDate, Tree<Pair<Account, BigDecimal>>> balanceSheets, LocalDate startDate, Writer writer) {
        templateEngine.write(STRING_BALANCE_SHEET, balanceSheetModel(balanceSheets, startDate), writer);
    }

    private static Map<String, Object> incomeStatementModel(NavigableMap<LocalDate, Tree<Pair<Account, BigDecimal>>> incomeStatements, LocalDate startDate) {
        final List<Map<String, Object>> iss = new ArrayList<>(incomeStatements.size());
        LocalDate lastDate = startDate;
        for (Map.Entry<LocalDate, Tree<Pair<Account, BigDecimal>>> entry: incomeStatements.entrySet()) {
            final Map<String, Object> map = new HashMap<>();
            map.put("startDate", lastDate);
            map.put("endDate", entry.getKey().plusDays(-1L));
            final Tree<Pair<Account, BigDecimal>> balanceTree = entry.getValue();
            map.put("hasBalanceTree", balanceTree != null);
            if (balanceTree != null) {
                map.put("balanceTree", balanceTreeToString(balanceTree));
                final Map<Account.Type, BigDecimal> typeToAmount =
                        balanceTree.getChildren().stream()
                                .map(Tree::getValue)
                                .collect(groupingBy(pair -> pair.first().getType(),
                                        reducing(BigDecimal.ZERO, Pair::second, BigDecimal::add)));
                final BigDecimal revenue = typeToAmount.getOrDefault(Account.Type.REVENUE, BigDecimal.ZERO);
                final BigDecimal expense = typeToAmount.getOrDefault(Account.Type.EXPENSE, BigDecimal.ZERO);
                map.put("revenue", revenue.negate().toPlainString());
                map.put("expense", expense.toPlainString());
                map.put("profitOrLoss", revenue.add(expense).negate().toPlainString());
            }
            iss.add(map);
            lastDate = entry.getKey();
        }

        return Collections.singletonMap("incomeStatements", iss);
    }

    public String incomeStatement(NavigableMap<LocalDate, Tree<Pair<Account, BigDecimal>>> incomeStatements, LocalDate startDate) {
        final StringWriter writer = new StringWriter();
        writeIncomeStatement(incomeStatements, startDate, writer);
        return writer.toString();
    }

    public void writeIncomeStatement(NavigableMap<LocalDate, Tree<Pair<Account, BigDecimal>>> incomeStatements, LocalDate startDate, Writer writer) {
        templateEngine.write(STRING_INCOME_STATEMENT, incomeStatementModel(incomeStatements, startDate), writer);
    }

    private static List<Map<String, Object>> flowToList(Map<Account, BigDecimal[]> flow, Set<Account> accounts, ChartOfAccounts chartOfAccounts) {
        final List<Map<String, Object>> result = flow.entrySet().stream()
                .sorted(Comparator.comparing(Map.Entry::getKey, Comparators.accountComparator))
                .map(entry -> {
                    final Map<String, Object> map = new HashMap<>();
                    map.put("account", accountModel(entry.getKey(), chartOfAccounts));
                    map.put("isZeroDebit", entry.getValue()[0].signum() == 0);
                    map.put("debit", amount(entry.getValue()[0]));
                    map.put("isZeroCredit", entry.getValue()[1].signum() == 0);
                    map.put("credit", amount(entry.getValue()[1]));
                    return map;
                }).collect(Collectors.toList());

        if (!accounts.isEmpty()) {
            for (Account account: accounts) {
                if (!flow.containsKey(account)) {
                    final Map<String, Object> map = new HashMap<>();
                    map.put("account", accountModel(account, chartOfAccounts));
                    map.put("isZeroDebit", true);
                    map.put("debit", amount(BigDecimal.ZERO));
                    map.put("isZeroCredit", true);
                    map.put("credit", amount(BigDecimal.ZERO));
                    result.add(map);
                }
            }
        }

        return result;
    }

    private static BigDecimal[] flowToAmounts(Map<Account, BigDecimal[]> flow) {
        return flow.entrySet().stream()
                .map(Map.Entry::getValue)
                .collect(amountsCollector());
    }

    private static Map<String, Object> cashFlowModel(NavigableMap<LocalDate, Map<Boolean, Map<Account, BigDecimal[]>>> cashFlows, LocalDate startDate, Set<Account> cashAccounts, ChartOfAccounts chartOfAccounts) {
        final List<Map<String, Object>> cfs = new ArrayList<>(cashFlows.size());
        LocalDate lastDate = startDate;
        for (Map.Entry<LocalDate, Map<Boolean, Map<Account, BigDecimal[]>>> entry: cashFlows.entrySet()) {
            final Map<String, Object> map = new HashMap<>();
            map.put("startDate", lastDate);
            map.put("endDate", entry.getKey().plusDays(-1L));
            final Map<Account, BigDecimal[]> cash = entry.getValue().get(true);
            final BigDecimal[] cashAmounts = flowToAmounts(cash);
            map.put("cash", flowToList(cash, cashAccounts, chartOfAccounts));
            map.put("cashDebit", cashAmounts[0].toPlainString());
            map.put("cashCredit", cashAmounts[1].negate().toPlainString());
            map.put("cashDifference", cashAmounts[0].add(cashAmounts[1]).toPlainString());
            final Map<Account, BigDecimal[]> other = entry.getValue().get(false);
            final BigDecimal[] otherAmounts = flowToAmounts(other);
            map.put("other", flowToList(other, Collections.emptySet(), chartOfAccounts));
            map.put("otherDebit", otherAmounts[0].toPlainString());
            map.put("otherCredit", otherAmounts[1].negate().toPlainString());
            map.put("otherDifference", otherAmounts[0].add(otherAmounts[1]).negate().toPlainString());
            cfs.add(map);
            lastDate = entry.getKey();
        }

        return Collections.singletonMap("cashFlows", cfs);
    }

    public String cashFlow(NavigableMap<LocalDate, Map<Boolean, Map<Account, BigDecimal[]>>> cashFlows, LocalDate startDate, Set<Account> cashAccounts, ChartOfAccounts chartOfAccounts) {
        final StringWriter writer = new StringWriter();
        writeCashFlow(cashFlows, startDate, cashAccounts, chartOfAccounts, writer);
        return writer.toString();
    }

    public void writeCashFlow(NavigableMap<LocalDate, Map<Boolean, Map<Account, BigDecimal[]>>> cashFlows, LocalDate startDate, Set<Account> cashAccounts, ChartOfAccounts chartOfAccounts, Writer writer) {
        templateEngine.write(STRING_CASH_FLOW, cashFlowModel(cashFlows, startDate, cashAccounts, chartOfAccounts), writer);
    }

    private static BigDecimal totalCount(Set<Item> items) {
        return items.stream()
                .collect(TransactionSummary.amountCollector(Item::getCount));
    }

    private static String itemsToString(Collection<Item> items) {
        if (items.size() == 1) {
            return items.iterator().next().getCost().toPlainString();
        } else if (items.size() <= 3) {
            return items.stream()
                    .map(Item::toPrettyString)
                    .collect(Collectors.joining(", "));
        } else {
            final String lineSep = System.lineSeparator();
            return items.stream()
                    .map(item -> "* " + item.toPrettyString())
                    .collect(Collectors.joining(lineSep, lineSep, lineSep));
        }
    }

    private static String costBalanceToString(Map<BigDecimal, BigDecimal> costBalance) {
        if (costBalance.size() <= 3) {
            return costBalance.entrySet().stream()
                    .map(e -> e.getValue().toPlainString() + " @ " + e.getKey().toPlainString())
                    .collect(Collectors.joining(", "));
        } else {
            final String lineSep = System.lineSeparator();
            return costBalance.entrySet().stream()
                    .map(e -> "* " + e.getValue().toPlainString() + " @ " + e.getKey().toPlainString())
                    .collect(Collectors.joining(lineSep, lineSep, lineSep));
        }
    }

    private static Map<String, Object> inventoryEntryModel(InventoryEntry inventoryEntry) {
        final Map<String, Object> map = new HashMap<>();
        map.put("date", inventoryEntry.getDate());
        final BigDecimal totalCount = totalCount(inventoryEntry.getItems());
        map.put("totalCount", totalCount.toPlainString());
        map.put("isReceived", totalCount.signum() != 0 && inventoryEntry.getTransactionEntry().isNormalBalance());
        map.put("isSold", totalCount.signum() != 0 && !inventoryEntry.getTransactionEntry().isNormalBalance());
        map.put("countBalance", inventoryEntry.getCountBalance().toPlainString());
        map.put("oneItem", inventoryEntry.getItems().size() == 1);
        map.put("manyItems", inventoryEntry.getItems().size() > 1);
        map.put("cost", itemsToString(inventoryEntry.getItems()));
        map.put("amount", inventoryEntry.getAmount().toPlainString());
        map.put("balance", inventoryEntry.getBalance().toPlainString());
        map.put("costBalance", costBalanceToString(inventoryEntry.getCostBalance()));
        map.put("isZeroCountBalance", inventoryEntry.getCountBalance().signum() == 0);
        map.put("averageCost", inventoryEntry.getAverageCost().map(BigDecimal::toPlainString).orElse(null));

        return map;
    }

    private static Map<String, Object> inventoryModel(Map<Commodity, List<InventoryEntry>> inventory) {
        return Collections.singletonMap("inventory", iterable(() -> inventory.entrySet().stream()
            .map(e -> {
                final Map<String, Object> map = new HashMap<>();
                map.put("commodity", commodityModel(e.getKey()));
                map.put("inventoryEntries", iterable(() -> e.getValue().stream().map(ToString::inventoryEntryModel).iterator()));
                return map;
            }).iterator()));
    }

    public String inventory(Map<Commodity, List<InventoryEntry>> inventory) {
        final StringWriter writer = new StringWriter();
        writeInventory(inventory, writer);
        return writer.toString();
    }

    public void writeInventory(Map<Commodity, List<InventoryEntry>> inventory, Writer writer) {
        templateEngine.write(STRING_INVENTORY, inventoryModel(inventory), writer);
    }
}
